/*
 * Fichier: JavaHighlighter.java
 * Crée le: 24 décembre 2008.
 * Modifié: 14 février 2009.
 * Auteurs: Sébastien ESTIENNE.
 * SiteWeb: http://www.prog-info.org/
 * 
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 */

package javaexos.tools;



import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PushbackReader;
import java.net.URL;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;



/**
 * <p>Cette classe permet d'analyser un code source java afin d'effectuer sur celui-ci une
 * coloration de la syntaxe.</p>
 */
public final class JavaHighlighter
{
   /** Logger. */
   private static final Logger LOGGER = Log.getInstance().getLogger(
         "javaexos.tools.JavaHighlighter");

   /** L'instance statique. */
   private static JavaHighlighter instance;

   /** Liste des mots clés dans l'ordre alphabétique en Java. */
   private static final String[] KEYWORDS = {"abstract", "assert", "boolean", "break", "byte",
         "case", "catch", "char", "class", "continue", "default", "do", "double", "else", "enum",
         "extends", "false", "final", "finally", "float", "for", "if", "implements", "import",
         "instanceof", "int", "interface", "long", "native", "new", "null", "package", "private",
         "protected", "public", "return", "short", "static", "super", "switch", "synchronized",
         "this", "throw", "throws", "transient", "true", "try", "void", "volatile", "while"};

   /** Liste des marqueurs Javadoc. */
   private static final String[] JAVADOC_TAGS = {"@author", "@code", "@docRoot", "@deprecated",
         "@exception", "@inheritDoc", "@link", "@linkplain", "@literal", "@param", "@return",
         "@see", "@serial", "@serialData", "@serialField", "@since", "@throws", "@value",
         "@version"};

   /** Balise pour les mots clés. */
   private static final String KEYWORD_MARKER = "<span class=\"keyword\">";

   /** Balise pour les nombres. */
   private static final String NUMBER_MARKER = "<span class=\"number\">";

   /** Balise pour les caractères. */
   private static final String CHAR_MARKER = "<span class=\"char\">";

   /** Balise pour les chaines de caractères. */
   private static final String STRING_MARKER = "<span class=\"string\">";

   /** Balise pour les commentaires. */
   private static final String COMMENT_MARKER = "<span class=\"comment\">";

   /** Balise pour les commentaires javadoc. */
   private static final String COMMENT2_MARKER = "<span class=\"comment2\">";

   /** Balise pour les annotations. */
   private static final String ANNOTATION_MARKER = "<span class=\"annotation\">";

   /** Balise de fermeture. */
   private static final String CLOSE_MARKER = "</span>";

   /** Balise d'ouverture de mise en gras. */
   private static final String BEGIN_STRONG_MARKER = "<b>";

   /** Balise de fermeture de mise en gras. */
   private static final String END_STRONG_MARKER = "</b>";

   /** Code html du caractère inférieur. */
   private static final String INF_HTML = "&lt;";

   /** Code html du caractère supérieur. */
   private static final String SUP_HTML = "&gt;";
   
   /** Code html du caractère et commercial. */
   private static final String AND_HTML = "&amp;";

   /** Lecteur pour le fichier à analyser. */
   private PushbackReader reader;

   /** Contient le code source java coloré. */
   private StringBuilder result;

   /** Contient du texte temporairement. */
   private StringBuilder temp;

   /** Indique si le dernier caractère était un caractère d'échappement. */
   private boolean echappment;

   /** Le code du caractère courant. */
   private int i;

   /** Le caractère courant. */
   private char c;


   /**
    * <p>Constructeur.</p>
    */
   private JavaHighlighter()
   {
      this.result = null;
      this.echappment = false;
      this.temp = new StringBuilder();
   }


   /**
    * <p>Récupère l'instance unique de la classe JavaHighlighter.<p>
    * @return Retourne l'instance unique de la classe JavaHighlighter.
    * @truite test.
    */
   public static JavaHighlighter getInstance()
   {
      if(instance == null)
      {
         instance = new JavaHighlighter();
      }

      return instance;
   }


   /**
    * <p>Analyse le fichier passé en paramètre et applique un coloration syntaxique sur le
    * contenu.</p>
    * @param url L'URL du chemin vers le fichier.
    * @return Retourne le texte avec la coloration de la syntaxe ou null s'il y a eu un problème.
    */
   public String parse(URL url)
   {
      // Initialise le texte du code source coloré.
      this.result = new StringBuilder();

      try
      {
         // Initialise le lecteur du fichier.
         this.reader = new PushbackReader(new InputStreamReader(url.openStream(), "UTF-8"));
         
         try
         {
            // Parcours le fichier caractère par caractère.
            while((this.i = this.reader.read()) != -1)
            {
               this.c = (char) this.i;
               switch(this.c)
               {
               case ' ':
                  this.result.append(this.c);
                  break;
               case '/':
                  parseComment();
                  break;
               case '"':
                  parseString();
                  break;
               case '\'':
                  parseChar();
                  break;
               case '@':
                  parseAnnotation();
                  break;
               default:
                  parseKeywordsAndNumbers();
                  break;
               }

               // Test si le caractère correspond à une fin de fichier.
               if(this.i == -1)
               {
                  this.result.append(this.temp);
                  this.temp.delete(0, this.temp.length());
                  break;
               }
            }
         }
         finally
         {
            // Ferme le lecteur de fichier.
            this.reader.close();
         }
      }
      catch(IOException ioe)
      {
         LOGGER.log(Level.SEVERE, url + " : " + ioe.getMessage());
         return null;
      }

      return "<pre>" + this.result.toString() + "</pre>";
   }


   /**
    * <p>Analyse le texte pour déterminer un commentaire.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseComment() throws IOException
   {
      // Ajoute le caractère à la chaine temporaire.
      this.temp.append(this.c);

      // Parcours le fichier caractère par caractère.
      this.i = this.reader.read();
      this.c = (char) this.i;
      switch(this.c)
      {
      case '/':
         parseSimpleComment();
         break;
      case '*':
         parseAdvancedComment();
         break;
      default:
         // Dans ce cas, ce n'est pas un commentaire.
         this.result.append(this.temp);
         this.temp.delete(0, this.temp.length());
         this.result.append(this.c);
         break;
      }
   }


   /**
    * <p>Analyse le texte pour déterminer un commentaire simple.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseSimpleComment() throws IOException
   {
      // Ajoute la balise de mise en forme de commentaire simple.
      this.result.append(COMMENT_MARKER);

      // Ajoute les "//".
      this.result.append(this.temp);
      this.temp.delete(0, this.temp.length());
      this.result.append(this.c);

      // Ajoute le reste de la ligne.
      while((this.i = this.reader.read()) != -1)
      {
         this.c = (char) this.i;
         switch(this.c)
         {
         // Retour à la ligne.
         case '\n':
            this.result.append(CLOSE_MARKER);
            this.result.append(this.c);
            return;
         // Retour chariot.
         case '\r':
            this.result.append(CLOSE_MARKER);
            this.result.append(this.c);
            this.i = this.reader.read();
            this.c = (char) this.i;
            if(this.c == '\n')
            {
               this.result.append(this.c);
            }
            else
            {
               this.reader.unread(this.i);
            }
            return;
         // Remplacement du signe < par son code html.
         case '<':
            this.result.append(INF_HTML);
            break;
         // Remplacement du signe > par son code html.
         case '>':
            this.result.append(SUP_HTML);
            break;
         // Remplacement du signe & par son code html.
         case '&':
            this.result.append(AND_HTML);
            break;
         default:
            this.result.append(this.c);
            break;
         }
      }
   }


   /**
    * <p>Analyse le texte pour déterminer un commentaire avancé.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseAdvancedComment() throws IOException
   {
      // Ajoute le caractère dans un chaine temporaire avec maitenant "/*".
      this.temp.append(this.c);

      // Lit le prochain caractère.
      this.i = this.reader.read();
      if(this.i == -1)
      {
         return;
      }
      this.c = (char) this.i;

      // Définit le type de commentaire pour la mise en forme.
      if(this.c == '*')
      {
         this.result.append(COMMENT2_MARKER);
      }
      else
      {
         this.result.append(COMMENT_MARKER);
      }

      // Ajoute les caractères de détection de commentaires.
      this.result.append(this.temp);
      this.temp.delete(0, this.temp.length());
      this.result.append(this.c);

      // Parcours du commentaire caractère par caractère.
      char cPrevious = '\0';
      while((this.i = this.reader.read()) != -1)
      {
         this.c = (char) this.i;
         switch(this.c)
         {
         // Marqueur de la javadoc.
         case '@':
            this.temp.append(this.c);
            while((this.i = this.reader.read()) != -1)
            {
               this.c = (char) this.i;
               if(this.c >= 97 && this.c <= 122 || this.c >= 65 && this.c <= 90)
               {
                  this.temp.append(this.c);
               }
               // Fin de la mise en forme du marqueur.
               else
               {
                  // Vérifie que le marqueur est bien un marqueur javadoc.
                  if(Arrays.binarySearch(JAVADOC_TAGS, this.temp.toString()) >= 0)
                  {
                     this.result.append(BEGIN_STRONG_MARKER);
                     this.result.append(this.temp);
                     this.result.append(END_STRONG_MARKER);
                  }
                  else
                  {
                     this.result.append(this.temp);
                  }
                  this.temp.delete(0, this.temp.length());
                  this.reader.unread(this.i);
                  break;
               }
            }
            break;
         // Remplacement du signe < par son code html.
         case '<':
            this.result.append(INF_HTML);
            break;
         // Remplacement du signe > par son code html.
         case '>':
            this.result.append(SUP_HTML);
            break;
         // Remplacement du signe & par son code html.
         case '&':
            this.result.append(AND_HTML);
            break;
         // Vérification de fin de caractères.
         case '/':
            if(cPrevious == '*')
            {
               this.result.append(this.c);
               this.result.append(CLOSE_MARKER);
               return;
            }
            this.result.append(this.c);
            break;
         default:
            this.result.append(this.c);
            break;
         }

         // Enregistre le dernier cacractère.
         cPrevious = this.c;
      }
   }


   /**
    * <p>Analyse le texte pour déterminer la chaine de caractères exacte.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseString() throws IOException
   {
      // Insère la balise de chaine de caractères et le caractère de chaine.
      this.result.append(STRING_MARKER);
      this.result.append(this.c);

      // Parcours de la chaine caractère par caractère.
      while((this.i = this.reader.read()) != -1)
      {
         this.c = (char) this.i;
         switch(this.c)
         {
         // Cas de caractère de fin de chaine.
         case '"':
            this.result.append(this.c);
            if(!this.echappment)
            {
               this.result.append(CLOSE_MARKER);
               return;
            }
            this.echappment = false;
            break;
         // Cas d'un caractère d'échappement.
         case '\\':
            this.echappment = !this.echappment;
            this.result.append(this.c);
            break;
         // Remplacement du signe <par son code html.
         case '<':
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(INF_HTML);
            break;
         // Remplacement du signe > par son code html.
         case '>':
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(SUP_HTML);
            break;
         // Remplacement du signe & par son code html.
         case '&':
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(AND_HTML);
            break;
         // Cas pour tous les autres caractères.
         default:
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(this.c);
            break;
         }
      }
   }


   /**
    * <p>Analyse le texte pour déterminer le caractère.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseChar() throws IOException
   {
      // Insère la balise de caractère et le caractère d'identification.
      this.result.append(CHAR_MARKER);
      this.result.append(this.c);

      // Parcours du caractère.
      while((this.i = this.reader.read()) != -1)
      {
         this.c = (char) this.i;
         switch(this.c)
         {
         // Cas de caractère de fin de chaine.
         case '\'':
            this.result.append(this.c);
            if(!this.echappment)
            {
               this.result.append(CLOSE_MARKER);
               return;
            }
            this.echappment = false;
            break;
         // Cas d'un caractère d'échappement.
         case '\\':
            this.echappment = !this.echappment;
            this.result.append(this.c);
            break;
         // Remplacement du signe < par son code html.
         case '<':
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(INF_HTML);
            break;
         // Remplacement du signe > par son code html.
         case '>':
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(SUP_HTML);
            break;
         // Remplacement du signe & par son code html.
         case '&':
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(AND_HTML);
            break;
         // Cas pour tous les autres caractères.
         default:
            if(this.echappment)
            {
               this.echappment = false;
            }
            this.result.append(this.c);
            break;
         }
      }
   }


   /**
    * <p>Analyse le texte pour déterminer le texte de l'annotation.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseAnnotation() throws IOException
   {
      // Insère la balise d'annotation et le caractère d'identification.
      this.result.append(ANNOTATION_MARKER);
      this.result.append(this.c);

      // Parcours le fichier caractère par caractère.
      while((this.i = this.reader.read()) != -1)
      {
         this.c = (char) this.i;
         // Caractères alphanumériques.
         if(this.c >= 97 && this.c <= 122 || this.c >= 65 && this.c <= 90)
         {
            this.result.append(this.c);
         }
         // Fin de la mise en forme de l'annotation.
         else
         {
            this.result.append(CLOSE_MARKER);
            this.reader.unread(this.i);
            return;
         }
      }
   }


   /**
    * <p>Analyse le texte pour trouver des nombres et des mots clés.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseKeywordsAndNumbers() throws IOException
   {
      // Mots clés.
      if(this.c >= 97 && this.c <= 122 || this.c >= 65 && this.c <= 90 || this.c == 95)
      {
         parseKeyword();
      }
      // Nombres
      else if(this.c >= 48 && this.c <= 57)
      {
         parseNumber();
      }
      // Autre caractère.
      else
      {
         // Si c'est un caractère html, remplacement par son code.
         switch(this.c)
         {
         case '<':
            this.result.append(INF_HTML);
            break;
         case '>':
            this.result.append(SUP_HTML);
            break;
         case '&':
            this.result.append(AND_HTML);
            break;
         default:
            this.result.append(this.c);
         }
      }
   }


   /**
    * <p>Analyse le texte pour identifier le nombre.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseNumber() throws IOException
   {
      // Ajoute la mise en forme des nomrbes et le premier chiffre.
      this.result.append(NUMBER_MARKER);
      this.result.append(this.c);

      // Parcours du nombre.
      while((this.i = this.reader.read()) != -1)
      {
         this.c = (char) this.i;
         switch(this.c)
         {
         // Chiffre ou '.'.
         case '0':
         case '1':
         case '2':
         case '3':
         case '4':
         case '5':
         case '6':
         case '7':
         case '8':
         case '9':
         case '.':
            this.result.append(this.c);
            break;
         // Nombre flottant.
         case 'f':
         case 'F':
            this.result.append(this.c);
            this.result.append(CLOSE_MARKER);
            return;
            // Nombre de type Long.
         case 'l':
         case 'L':
            this.result.append(this.c);
            this.result.append(CLOSE_MARKER);
            return;
            // Nombre avec valeur à la puissance 10.
         case 'e':
         case 'E':
            // Ajout de ce caractère.
            this.result.append(this.c);
            // Lit le caractère suivant.
            this.i = this.reader.read();
            if(this.i != -1)
            {
               this.c = (char) this.i;
               // Cas d'un nombre ou d'un signe.
               if(this.c == '+' || this.c == '-' || (this.c >= 48 && this.c <= 57))
               {
                  this.result.append(this.c);
               }
               // Nombre fini.
               else
               {
                  this.result.append(CLOSE_MARKER);
                  this.reader.unread(this.i);
                  return;
               }

               // Parcours de la partie exposant du nombre.
               while((this.i = this.reader.read()) != -1)
               {
                  this.c = (char) this.i;
                  // Cas d'un nombre.
                  if(this.c >= 48 && this.c <= 57)
                  {
                     this.result.append(this.c);
                  }
                  // La partie exposant du nombre est finie.
                  else
                  {
                     this.result.append(CLOSE_MARKER);
                     this.reader.unread(this.i);
                     return;
                  }
               }
            }
            break;
         // Fin du nombre.
         default:
            this.result.append(CLOSE_MARKER);
            this.reader.unread(this.i);
            return;
         }
      }
   }


   /**
    * <p>Analyse le texte pour identifier le mot clé.</p>
    * @throws IOException Si une erreur se produit lors de la lecture du fichier.
    */
   private void parseKeyword() throws IOException
   {
      // Ajoute le caractère à la chaine temporaire.
      this.temp.append(this.c);

      // Parcours du mot caractère par caractère.
      while((this.i = this.reader.read()) != -1)
      {
         this.c = (char) this.i;

         // Caractère alphabétique et underscore.
         if(this.c >= 97 && this.c <= 122 || this.c >= 65 && this.c <= 90 || this.c == 95
               || this.c >= 48 && this.c <= 57)
         {
            this.temp.append(this.c);
         }
         else
         {
            // L'expression est un mot clé.
            if(Arrays.binarySearch(KEYWORDS, this.temp.toString()) >= 0)
            {
               this.result.append(KEYWORD_MARKER);
               this.result.append(this.temp);
               this.result.append(CLOSE_MARKER);
            }
            // L'expression n'est pas un mot clé.
            else
            {
               this.result.append(this.temp);
            }

            // Vide la chaine temporaire et ajoute le caractère de séparation.
            this.temp.delete(0, this.temp.length());
            this.reader.unread(this.i);
            break;
         }
      }
   }
}
